Skip to content

refactor: eliminate N+1 queries in dashboard endpoints using bulk ser…#37

Merged
krishnapaljadeja merged 2 commits intogdg-charusat:mainfrom
Aryan-B-Parikh:refactor/fix-n1-query-dashboard
Feb 27, 2026
Merged

refactor: eliminate N+1 queries in dashboard endpoints using bulk ser…#37
krishnapaljadeja merged 2 commits intogdg-charusat:mainfrom
Aryan-B-Parikh:refactor/fix-n1-query-dashboard

Conversation

@Aryan-B-Parikh
Copy link
Copy Markdown
Contributor

@Aryan-B-Parikh Aryan-B-Parikh commented Feb 23, 2026

Team Number : Team 146

Linked Issue

Closes #34

Problem

getDashboard and getTodayStatus in dashboard.controller.js suffered from a severe N+1 query problem. For every active membership, two separate database queries were fired inside a Promise.all(memberships.map(...)) loop:

  • prisma.dailyResult.findUnique — one per membership for today's result
  • evaluationService.getMemberDailyResults → another prisma.dailyResult.findMany per membership for recent results

For a user in 15 challenges, this produced 30+ database queries per single API request, rapidly exhausting the PostgreSQL connection pool under load.

Solution

Refactored both endpoints to query the database in bulk and group data in memory, following standard ORM performance best practices.

Changes — src/services/evaluation.service.js

  • Added normaliseToMidnight() — a shared pure helper that normalises any Date to local midnight, eliminating repeated inline date mutation across the codebase.
  • Added getBulkTodayResults(memberIds) — fetches today's dailyResult rows for all members in one query, returns a { [memberId]: result } map for O(1) lookup.
  • Added getBulkMemberDailyResults(memberIds, daysBack) — fetches the last N calendar days of results for all members in one query, returns a { [memberId]: result[] } map. Uses a calendar window (not take: N) so missed days are correctly represented as absent entries in the activity strip.

Changes — src/controllers/dashboard.controller.js

  • getDashboard: removed all inline Prisma queries, date normalization, and reduce grouping. Delegates entirely to getBulkTodayResults + getBulkMemberDailyResults via Promise.all. Response mapping is now purely in-memory.
  • getTodayStatus: same treatment — removed the per-membership findUnique loop, replaced with a single call to getBulkTodayResults.
  • Added early-return guard for empty memberships in both endpoints.

Query Reduction

Endpoint Before After
GET /api/dashboard (15 challenges) 31 queries 3 queries
GET /api/dashboard/today (15 challenges) 16 queries 2 queries

Testing

── normaliseToMidnight ──────────────────────────────────────
✅ hours set to 0
✅ minutes set to 0
✅ seconds set to 0
✅ ms set to 0
✅ returns a new Date (no mutation)
✅ original date is not mutated

── getBulkTodayResults — in-memory grouping ─────────────────
✅ member A has a today result
✅ member A completed today
✅ member B has a today result
✅ member B did not complete today
✅ member C has no today result (absent = missed day)
✅ empty rows → empty map

── getBulkMemberDailyResults — in-memory grouping ───────────
✅ member A has 3 recent entries
✅ member B has 1 recent entry
✅ member C absent (no results in window)
✅ member A results are ordered newest-first
✅ empty rows → empty map

── getBulkAllMemberResults — leaderboard aggregation ────────
✅ member A totalDays = 3
✅ member A completedDays = 2
✅ member B totalDays = 3
✅ member B completedDays = 1
✅ member C absent → no entry
✅ member A completionRate = 66.67 (expected 66.67)
✅ empty rows → empty map

── getDashboard — controller response shaping ───────────────
✅ returns entry for every membership
✅ member A todayStatus populated
✅ member A todayStatus.completed correct
✅ member A has 3 recent results
✅ member B todayStatus populated
✅ member B todayStatus.completed correct
✅ member C todayStatus is null (no result)
✅ member C recentResults is empty array

── getTodayStatus — controller response shaping ─────────────
✅ returns entry for every membership
✅ member A challengeId correct
✅ member A status populated
✅ member A status.completed correct
✅ member A submissionsCount correct
✅ member B status.completed correct
✅ member C status is null

── getChallengeLeaderboard — response shaping ───────────────
✅ returns entry for every member
✅ alice is first (order preserved from DB sort)
✅ alice totalDays = 3
✅ alice completedDays = 2
✅ alice completionRate = 66.67
✅ bob completedDays = 1
✅ bob completionRate = 33.33
✅ carol totalDays = 0 (no results)
✅ carol completionRate = 0.00
✅ carol leetcodeUsername null handled safely

── Edge cases ───────────────────────────────────────────────
✅ empty memberships array triggers early return
✅ groupTodayResults([]) → {}
✅ groupRecentResults([]) → {}
✅ aggregateAllResults([]) → {}
✅ last row wins for duplicate memberId (consistent with DB unique constraint)

────────────────────────────────────────────────────────────
Results: 54 passed, 0 failed
ALL TESTS PASSED ✅

…vice methods

- Replace per-membership prisma.dailyResult loops in getDashboard and
  getTodayStatus with two bulk queries using memberId: { in: memberIds }
- Add getBulkTodayResults() and getBulkMemberDailyResults() service methods
  that fetch all results in one query and return maps keyed by memberId
- Add normaliseToMidnight() helper to centralise date normalisation logic
- Group results in-memory via reduce for O(1) controller-level lookup
- Add normaliseToMidnight() helper to centralise date normalisation logic
- Fixes 30+ DB queries per request (15 challenges) down to 3 queries for
  getDashboard and 2 for getTodayStatus

Closes gdg-charusat#21
Copilot AI review requested due to automatic review settings February 23, 2026 17:15
@krishnapaljadeja krishnapaljadeja self-requested a review February 23, 2026 17:16
@krishnapaljadeja
Copy link
Copy Markdown
Contributor

⚠️ PR Validation Failed

Hey @Aryan-B-Parikh! Your PR is missing a required field:

Team Number missing — add your team number anywhere in the PR description.
Example: Team 07

How to fix:

  1. Click the pencil ✏️ icon on your PR description
  2. Add your team number (e.g. Team 07)
  3. Save — this check will re-run automatically

GDG CHARUSAT Open Source Contri Sprintathon

@krishnapaljadeja krishnapaljadeja added invalid-pr PR is missing required information needs-review Valid issue-linked PR awaiting review and removed invalid-pr PR is missing required information labels Feb 23, 2026
@krishnapaljadeja
Copy link
Copy Markdown
Contributor

✅ PR Validation Passed

Hey @Aryan-B-Parikh! Your PR looks good. Here is what we found:

Field Value
Team Number Team 146
Linked Issue Closes #34

A maintainer will review your PR within 24–48 hours. Stay responsive to feedback!

GDG CHARUSAT Open Source Contri Sprintathon

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR refactors the getDashboard and getTodayStatus endpoints in dashboard.controller.js to eliminate N+1 query problems by introducing bulk query functions in evaluation.service.js. Previously, each endpoint made 2 separate database queries per active membership (one for today's result, one for recent results), resulting in 30+ queries for a user with 15 challenges. The refactoring consolidates these into 2-3 bulk queries total, regardless of membership count.

Changes:

  • Added three new functions to evaluation.service.js: normaliseToMidnight() helper, getBulkTodayResults(), and getBulkMemberDailyResults() for bulk data fetching with in-memory grouping
  • Refactored getDashboard and getTodayStatus controllers to use bulk query functions instead of per-membership queries, with early-return guards for empty memberships
  • Reduced database queries from 31 to 3 for getDashboard and 16 to 2 for getTodayStatus (with 15 challenges)

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/services/evaluation.service.js Adds normaliseToMidnight() helper and two bulk query functions (getBulkTodayResults, getBulkMemberDailyResults) that fetch and group daily results for multiple members in single queries
src/controllers/dashboard.controller.js Refactors getDashboard and getTodayStatus to eliminate N+1 queries by delegating to new bulk query functions and performing data mapping in memory

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/controllers/dashboard.controller.js Outdated
Comment on lines 186 to 208
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getChallengeLeaderboard endpoint still has an N+1 query pattern similar to what was fixed in getDashboard and getTodayStatus. For each member, it queries prisma.dailyResult.findMany individually. Consider refactoring this to use a bulk query approach similar to getBulkMemberDailyResults to improve performance when there are many members in a challenge.

Copilot uses AI. Check for mistakes.
Comment on lines 396 to 410
const getTodayStatus = async (memberId) => {
const today = new Date();
today.setHours(0, 0, 0, 0);

return await prisma.dailyResult.findUnique({
where: {
challengeId_memberId_date: {
challengeId: (
await prisma.challengeMember.findUnique({ where: { id: memberId } })
).challengeId,
memberId,
date: today,
},
},
});
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getTodayStatus function should use the new normaliseToMidnight helper for consistency with the new bulk functions and the rest of the codebase. Currently, it still uses the inline setHours pattern, which is what the new helper was designed to replace.

Copilot uses AI. Check for mistakes.
@@ -1 +1 @@
const { prisma } = require("../config/prisma");
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description states "Closes #21" but the linked issue #21 is about "Missing caching for leaderboard API" which is unrelated to the N+1 query problem being addressed in this PR. The issue reference appears to be incorrect or the wrong issue was linked.

Copilot uses AI. Check for mistakes.
- Fix N+1 in getChallengeLeaderboard: replace per-member prisma loop
  with getBulkAllMemberResults() which fetches all dailyResult rows for
  all members in one query (selecting only memberId + completed) and
  aggregates totalDays/completedDays in-memory
- Use normaliseToMidnight() in getTodayStatus service function instead
  of inline setHours pattern, consistent with new helper convention
- Export getBulkAllMemberResults from evaluation.service.js
@krishnapaljadeja krishnapaljadeja added needs-review Valid issue-linked PR awaiting review pr-noted and removed needs-review Valid issue-linked PR awaiting review labels Feb 23, 2026
@krishnapaljadeja krishnapaljadeja merged commit 228818e into gdg-charusat:main Feb 27, 2026
2 checks passed
@krishnapaljadeja
Copy link
Copy Markdown
Contributor

🎉 PR Merged — Points Awarded!

Congratulations @Aryan-B-Parikh! Your contribution has been merged.

Field Value
Repo Code_duel_backend
Team Team 146
Contributor @Aryan-B-Parikh
Level Level 2 — Intermediate
Points Awarded 20 pts
Source Linked Issue #34

The central leaderboard has been updated. Keep contributing!

GDG CHARUSAT Open Source Contri Sprintathon

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-review Valid issue-linked PR awaiting review pr-noted

Projects

None yet

Development

Successfully merging this pull request may close these issues.

refactor: Resolve N+1 Query Problem in Dashboard API Endpoints

3 participants